﻿
package extremefx.gis {
	import extremefx.StringBuilder;
	import extremefx.i18n.IFormatProvider;
	import extremefx.i18n.IFormattable;
	import extremefx.tools.StringUtils;	

	/**
	 * @author Marcelo Volmaro
	 */
	public final class Coord implements IFormattable {
		private const _rad:Number = Math.PI / 180;
		private const _deg:Number = 180 / Math.PI;
		private const _r:Number = 6378137;//earth's mean radius in m
		
		private var _long:Number;	// Expressed in seconds of degree, positive values for east
		private var _lat:Number;	// Expressed in seconds of degree, positive values for north
		
		private var _latD:Number;
		private var _latM:Number;
		private var _latS:Number;
		private var _north:Boolean;
		private var _latDirty:Boolean;
		
		private var _lonD:Number;
		private var _lonM:Number;
		private var _lonS:Number;
		private var _east:Boolean;
		private var _lonDirty:Boolean;
		
		/**
		 * Initializes a new instance of the Coord class.
		 * @param pLat latitude, in degrees.
		 * @param pLong longitude, in degrees.
		 */
		public function Coord(pLat:Number = 0, pLong:Number = 0) {// Values expressed in degrees, for user convenience
			_lat = pLat * 3600;
			_long = pLong * 3600;
			
			_latDirty = _lonDirty = true;
		}
		
		/**
		 * Gets the longitude of this instance.
		 * @return the longitude, in degrees.
		 */
		public function get longitude():Number {
			return _long / 3600;
		}
		
		/**
		 * Sets the longitude of this instance.
		 * @param pVal the longitude, in degrees.
		 */
		public function set longitude(pVal:Number):void {
			_long = pVal * 3600;
			_lonDirty = true;
		}
		
		/**
		 * Gets the latitude of this instance.
		 * @return the latitude, in degrees.
		 */
		public function get latitude():Number {
			return _lat / 3600;
		}
		
		/**
		 * Sets the latitude of this instance.
		 * @param pVal the latitude, in degrees.
		 */
		public function set latitude(pVal:Number):void {
			_lat = pVal * 3600;
			_latDirty = true;
		}
		
		/**
		 * Gets the degrees component of the latitude represented by this instance.
		 * @return the integral part of the latitude, in degrees.
		 */
		public function get latitudeD():Number {
			_dirtyLat();
			return _latD;
		}
		
		/**
		 * Gets the minute component of the latitude represented by this instance.
		 * @return The minute component, expressed as a value between 0 and 59.
		 */
		public function get latitudeM():Number {
			_dirtyLat();
			return _latM;
		}
		
		/**
		 * Gets the seconds component of the latitude represented by this instance.
		 * @return The seconds component, expressed as a value between 0 and 59.
		 */
		public function get latitudeS():Number {
			_dirtyLat();
			return _latS;
		}
		
		/**
		 * Gets the north component of the latitude represented by this instance.
		 * @return true if north, false if south
		 */
		public function get north():Boolean {
			_dirtyLat();
			return _north;
		}
		
		/**
		 * Gets the degrees component of the longitude represented by this instance.
		 * @return the integral part of the longitude, in degrees.
		 */
		public function get longitudeD():Number {
			_dirtyLong();
			return _lonD;
		}
		
		/**
		 * Gets the minute component of the longitude represented by this instance.
		 * @return The minute component, expressed as a value between 0 and 59.
		 */
		public function get longitudeM():Number {
			_dirtyLong();
			return _lonM;
		}
		
		/**
		 * Gets the seconds component of the longitude represented by this instance.
		 * @return The seconds component, expressed as a value between 0 and 59.
		 */
		public function get longitudeS():Number {
			_dirtyLong();
			return _lonS;
		}
		
		/**
		 * Gets the east component of the latitude represented by this instance.
		 * @return true if east, false if west
		 */
		public function get east():Boolean {
			_dirtyLong();
			return _east;
		}
		
		private function _dirtyLat():void {
			if (_latDirty){
				_latDirty = false;
				var lat:Number = _lat;
				_north = lat >= 0;
	            lat = Math.abs(lat);
	            _latD = int(lat / 3600);
	            lat -=  _latD * 3600;
	            _latM = int(lat / 60);
	            _latS = lat - _latM * 60;
			}
		}
		
		private function _dirtyLong():void {
			if (_lonDirty) {
				_lonDirty = false;
			
				var lon:Number = _long;
				_east = lon >= 0;
	            lon = Math.abs(lon);
	            _lonD = int(lon / 3600);
	            lon -=  _lonD * 3600;
	            _lonM = int(lon / 60);
	            _lonS = lon - _lonM * 60;
			}
		}
		
		/**
		 * Sets the latitude component of this instance, in degrees, minutes, seconds and north.
		 * @param pDeg Degrees of the latitude
		 * @param pMin Minutes of the latitude
		 * @param pSec Seconds of the latitude
		 * @param pNorth north = true, south = false
		 */
		public function setLatDMS(pDeg:Number, pMin:Number, pSec:Number, pNorth:Boolean):void{
			_lat = (pDeg * 3600 + pMin * 60 + pSec) * (pNorth ? 1 : -1);
		}
		
		/**
		 * Sets the longitude component of this instance, in degrees, minutes, seconds and north.
		 * @param pDeg Degrees of the longitude
		 * @param pMin Minutes of the longitude
		 * @param pSec Seconds of the longitude
		 * @param pEast east = true, west = false
		 */
		public function setLongDMS(pDeg:Number, pMin:Number, pSec:Number, pEast:Boolean):void{
			_long = (pDeg * 3600 + pMin * 60 + pSec) * (pEast ? 1 : -1);
		}
		
		/**
		 * Calculates the destination point travelling along a (shortest distance) great circle arc 
		 * from this point, initial bearing (deg) and distance (m).
		 * @param pDistance distance in meters.
		 * @param pBearing initial bearing.
		 * @return the destination point.
		 */
		public function getDestinationPoint(pDistance:Number, pBearing:Number):Coord {
			var lon1:Number = _rad * longitude;
            var lat1:Number = _rad * latitude;
            pBearing *= _rad;
            var dr:Number = pDistance/_r;
            var pi:Number = Math.PI;
            
            var lat2:Number = 	Math.asin( Math.sin(lat1)*Math.cos(dr) + 
                        		Math.cos(lat1)*Math.sin(dr)*Math.cos(pBearing));
                        		
  			var lon2:Number = lon1 +	Math.atan2(Math.sin(pBearing)*Math.sin(dr)*Math.cos(lat1), 
                               			Math.cos(dr)-Math.sin(lat1)*Math.sin(lat2));
                               			
  			lon2 = (lon2+pi)%(2*pi) - pi;  // normalise to -180...+180

  			if (isNaN(lat2) || isNaN(lon2)) return null;
  			return new Coord(lat2 * _deg, lon2 * _deg);
		}
		
		/**
		 * <p>This function calculates great-circle distances between the two points – that is, the shortest distance over the earth’s surface – using the ‘Haversine’ formula.</p>
		 * <p>It assumes a spherical earth, ignoring ellipsoidal effects – which is accurate enough* for most purposes… – giving an ‘as-the-crow-flies’ distance between the two points (ignoring any hills!).</p>
		 * 
		 * <p>(*) Since the earth is not quite a sphere, there are small errors in using spherical geometry; 
		 * the earth is actually roughly ellipsoidal (or more precisely, oblate spheroidal) with 
		 * a radius varying between about 6,378km (equatorial) and 6,357km (polar), and local radius 
		 * of curvature varying from 6,336km (equatorial meridian) to 6,399km (polar). 
		 * This means that errors from assuming spherical geometry might be up to 0.55% crossing 
		 * the equator, though generally below 0.3%, depending on latitude and direction of travel. 
		 * An accuracy of better than 3m in 1km is good enough for me, but if you want greater accuracy, 
		 * you could use the Vincenty formula for calculating geodesic distances on ellipsoids, 
		 * which gives results accurate to within 1mm.</p>
		 * @return a GeodesicDistance instance with the distance information. 
		 */
		
		public function getHarversineDistance(pOther:Coord):GeodesicDistance {
            var lon1:Number = _rad * -longitude;
            var lat1:Number = _rad * latitude;
            var lon2:Number = _rad * -pOther.longitude;
            var lat2:Number = _rad * pOther.latitude;
            var d:Number = 2 * Math.asin(
            	Math.sqrt(
                	Math.pow(
                		Math.sin((lat1 - lat2) / 2), 2) + 
                		Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin((lon1 - lon2) / 2), 2)));
	
            return new GeodesicDistance(
            	(1852 * 60 * d/_rad), 
				_calcBearing(lat1, lon1, lat2, lon2), 
				(_calcBearing(lat2, lon2, lat1, lon1) + 180) % 180);
		}
		
		private function _calcBearing(pLat1:Number, pLon1:Number, pLat2:Number, pLon2:Number):Number{
			var dLon:Number = pLon2 - pLon1;
			return (((Math.atan2(Math.sin(dLon) * Math.cos(pLat2), Math.cos(pLat1)*Math.sin(pLat2) - Math.sin(pLat1)*Math.cos(pLat2)*Math.cos(dLon))) * _deg) + 360) % 360;
		}
		
		/**
		 * <p>Vincenty’s formula is accurate to within 0.5mm, or 0.000015" (!), on the ellipsoid being used. 
		 * Calculations based on a spherical model, such as the (much simpler) Haversine, are accurate 
		 * to around 0.3% (which is still good enough for most purposes, of course).</p>
		 * <p>Note: the accuracy quoted by Vincenty applies to the theoretical ellipsoid being used, 
		 * which will differ (to varying degree) from the real earth geoid. If you happen to be located 
		 * in Colorado, 2km above msl, distances will be 0.03% greater. In the UK, if you measure 
		 * the distance from Land’s End to John O’ Groats using WGS-84, it will be 28m – 0.003% – 
		 * greater than using the Airy ellipsoid, which provides a better fit for the UK.</p>
		 * @return a GeodesicDistance instance with the distance information.
		 */
		public function getVincentyDistance(pOther:Coord):GeodesicDistance {
			var lon1:Number = _rad * -longitude;
            var lat1:Number = _rad * latitude;
            var lon2:Number = _rad * -pOther.longitude;
            var lat2:Number = _rad * pOther.latitude;
            
			var a:Number = 6378137, b:Number = 6356752.3142, f:Number = 1/298.257223563;  // WGS-84 ellipsiod
			var L:Number = lon2-lon1;
			var U1:Number = Math.atan((1-f) * Math.tan(lat1));
			var U2:Number = Math.atan((1-f) * Math.tan(lat2));
			var sinU1:Number = Math.sin(U1), cosU1:Number = Math.cos(U1);
			var sinU2:Number = Math.sin(U2), cosU2:Number = Math.cos(U2);
			  
			var lambda:Number = L, lambdaP:Number = 2*Math.PI;
			var iterLimit:Number = 20;
			
			while (Math.abs(lambda-lambdaP) > 1e-12 && --iterLimit>0) {
				var sinLambda:Number = Math.sin(lambda), cosLambda:Number = Math.cos(lambda);
				var sinSigma:Number = Math.sqrt((cosU2*sinLambda) * (cosU2*sinLambda) + (cosU1*sinU2-sinU1*cosU2*cosLambda) * (cosU1*sinU2-sinU1*cosU2*cosLambda));
				
				if (sinSigma==0) return new GeodesicDistance(0, 0, 0);  // co-incident points
				
				var cosSigma:Number = sinU1*sinU2 + cosU1*cosU2*cosLambda;
				var sigma:Number = Math.atan2(sinSigma, cosSigma);
				var sinAlpha:Number = cosU1 * cosU2 * sinLambda / sinSigma;
				var cosSqAlpha:Number = 1 - sinAlpha*sinAlpha;
				var cos2SigmaM:Number = cosSigma - 2*sinU1*sinU2/cosSqAlpha;
				
				if (isNaN(cos2SigmaM)) cos2SigmaM = 0;  // equatorial line: cosSqAlpha=0 (§6)
				
				var C:Number = f/16*cosSqAlpha*(4+f*(4-3*cosSqAlpha));
				lambdaP = lambda;
				lambda = L + (1-C) * f * sinAlpha * (sigma + C*sinSigma*(cos2SigmaM+C*cosSigma*(-1+2*cos2SigmaM*cos2SigmaM)));
			}
			
			if (iterLimit==0) return null;  
			// formula failed to converge
			
			var uSq:Number = cosSqAlpha * (a*a - b*b) / (b*b);
			var A:Number = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq)));
			var B:Number = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq)));
			var deltaSigma:Number = B*sinSigma*(cos2SigmaM+B/4*(cosSigma*(-1+2*cos2SigmaM*cos2SigmaM)-B/6*cos2SigmaM*(-3+4*sinSigma*sinSigma)*(-3+4*cos2SigmaM*cos2SigmaM)));
			
			return new GeodesicDistance(
				b*A*(sigma-deltaSigma),
				Math.atan2(cosU2*sinLambda, cosU1*sinU2-sinU1*cosU2*cosLambda),
				Math.atan2(cosU1*sinLambda, -sinU1*cosU2+cosU1*sinU2*cosLambda)
				);
		}

		/**
		 * Parse coordinate in the following ISO 6709 formats:
		 * 
		 * Latitude and Longitude in Degrees:
		 * ±DD.DDDD±DDD.DDDD/         (eg +12.345-098.765/)
		 * 
		 * Latitude and Longitude in Degrees and Minutes:
		 * ±DDMM.MMMM±DDDMM.MMMM/     (eg +1234.56-09854.321/)
		 * 
		 * Latitude and Longitude in Degrees, Minutes and Seconds:
		 * ±DDMMSS.SSSS±DDDMMSS.SSSS/ (eg +123456.7-0985432.1/)
		 * 
		 * Latitude, Longitude (in Degrees) and Altitude:
		 * ±DD.DDDD±DDD.DDDD±AAA.AAA/         (eg +12.345-098.765+15.9/)
		 * 
		 * Latitude, Longitude (in Degrees and Minutes) and Altitude:
		 * ±DDMM.MMMM±DDDMM.MMMM±AAA.AAA/     (eg +1234.56-09854.321+15.9/)
		 * 
		 * Latitude, Longitude (in Degrees, Minutes and Seconds) and Altitude:
		 * ±DDMMSS.SSSS±DDDMMSS.SSSS±AAA.AAA/ (eg +123456.7-0985432.1+15.9/)
		 */
		
		public function parseIsoCoords(pCoords:String):void {
			if (pCoords.length < 18) return;				// Check for minimum length
            if (!StringUtils.endsWith(pCoords, "/")) return;	// Check for trailing slash
            
            pCoords = pCoords.substr(0, pCoords.length - 1);// Remove trailing slash
            
            var parts:Array = pCoords.split(/\+\-/);
            
            if (parts.length < 3 || parts.length > 4)  // Check for parts count
                parts = null;
                
            if (parts[0].length != 0)  // Check if first part is empty
                parts = null;

            var point:int = parts[1].IndexOf('.');
            
            if (point != 2 && point != 4 && point != 6) // Check for valid lenght for lat/lon
                parts = null;
                
            if (point != parts[2].IndexOf('.') - 1) // Check for lat/lon decimal positions
                parts = null;

            // Parse latitude and longitude values, according to format
            if (point == 2) {
                _lat = parseFloat(parts[1]) * 3600;
                _long = parseFloat(parts[2]) * 3600;
                
            } else if (point == 4) {
                _lat = parseFloat(parts[1].substring(0, 2)) * 3600 + parseFloat(parts[1].substring(2)) * 60;
                _long = parseFloat(parts[2].substring(0, 3)) * 3600 + parseFloat(parts[2].substring(3)) * 60;
                
            } else {// point==8
                _lat = parseFloat(parts[1].substring(0, 2)) * 3600 + parseFloat(parts[1].substring(2, 2)) * 60 + parseFloat(parts[1].substring(4));
                _long = parseFloat(parts[2].substring(0, 3)) * 3600 + parseFloat(parts[2].substring(3, 2)) * 60 + parseFloat(parts[2].substring(5));
            }
            
            // Parse altitude, just to check if it is valid
            if (parts.length == 4) parseFloat(parts[3]);

            // Add proper sign to lat/lon
            if (pCoords[0] == '-') _lat = -_lat;
            if (pCoords[parts[1].length + 1] == '-') _long = -_long;
		}

		public static function tryParseCoords(pLat:String, pLong:String):Coord{
			var lat:Number = _parse(pLat);
			var long:Number = _parse(pLong);
			
			if (isNaN(lat) || isNaN(long)) return null;
			return new Coord(lat, long);  
		}

// this is very flexible on formats, allowing signed decimal degrees, or deg-min-sec suffixed by 
// compass direction (NSEW). A variety of separators are accepted (eg 3º 37' 09"W) or fixed-width 
// format without separators (eg 0033709W). Seconds and minutes may be omitted. (Minimal validation 
// is done).		
		private static function _parse(pCoords:String):Number{
			var degLL:String = pCoords.replace(/^-/,'').replace(/[NSEW]/i,'');		// strip off any sign or compass dir'n
			var dms:Array = degLL.split(/[^0-9.]+/);								// split out separate d/m/s
			for (var i:String in dms) if (dms[i]=='') dms.splice(i,1);					// remove empty elements
			
			var deg:Number;
			
			switch (dms.length) {													// convert to decimal degrees...
				case 3:																// interpret 3-part result as d/m/s
					deg = dms[0]/1 + dms[1]/60 + dms[2]/3600; 
				break;
				
				case 2:																// interpret 2-part result as d/m
					deg = dms[0]/1 + dms[1]/60;
				break;
				
				case 1:																// decimal or non-separated dddmmss
					if (/[NS]/i.test(pCoords)) degLL = '0' + degLL;					// - normalise N/S to 3-digit degrees
					deg = dms[0].slice(0,3)/1 + dms[0].slice(3,5)/60 + dms[0].slice(5)/3600;
				break;
				
				default:
					return Number.NaN;
			}
			
			if (/^-/.test(pCoords) || /[WS]/i.test(pCoords)) deg = -deg;			// take '-', west and south as -ve
			return deg;
		}
		
		public function toString(pFormat:String = "", pFProvider:IFormatProvider = null):String {
			var sb:StringBuilder = new StringBuilder();

            if (pFormat == null) pFormat = "DMS";

            switch (pFormat.toUpperCase()) {
                case "":
                case "DMS":
                    sb.appendFormat("{0:0#}°{1:0#}'{2:0#.0}\"{3} ", 
                    	latitudeD, latitudeM, latitudeS, (north ? "N" : "S"));
                    	
                    sb.appendFormat("{0:0##}°{1:0#}'{2:0#.0}\"{3}", 
                    	longitudeD, longitudeM, longitudeS, (east ? "E" : "W"));
				break;
                    
                case "D":
                    sb.appendFormat("{0:0#.0000}°{1} ", Math.abs(latitude), (_lat > 0 ? "N" : "S"));
                    sb.appendFormat("{0:0##.0000}°{1}", Math.abs(longitude), (_lat > 0 ? "E" : "W"));
				break;
				
                case "DM":
                	var b:Number = Math.abs(_lat);
                	var c:Number = Math.abs(_long);
                	
                    sb.appendFormat("{0:0#}°{1:0#.00}'{2} ", 
                    	latitudeD, (b - int(b / 3600) * 3600) / 60, (north ? "N" : "S"));
                    sb.appendFormat("{0:0##}°{1:0#.00}'{2}", 
                    	longitudeD, (c - int(c / 3600) * 3600) / 60, (east ? "E" : "W"));
				break;
				
                case "ISO":
                    // Write coordinate according with ISO 6709
                    // Latitude and Longitude in Degrees, Minutes and Seconds:
                    // ±DDMMSS.SSSS±DDDMMSS.SSSS/ (eg +123456.7-0985432.1/)

                    sb.appendFormat("{0}{1:0#}{2:0#}{3:0#.0###}", (north ? "+" : "-"), latitudeD, latitudeM, latitudeS);
                    sb.appendFormat("{0}{1:0##}{2:0#}{3:0#.0###}/", (east ? "+" : "-"), longitudeD, longitudeM, longitudeS);
                    break;
                    
                default:
                    throw new Error("Coord.toString(): Invalid formatting string.");
            }
            
            return sb.toString();
		}
	}
}
